Skip to content

Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker#2516

Merged
SamMorrowDrums merged 8 commits into
rosstarrant/add-csv-output-structurefrom
sammorrowdrums/pr-2450-followup
May 21, 2026
Merged

Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker#2516
SamMorrowDrums merged 8 commits into
rosstarrant/add-csv-output-structurefrom
sammorrowdrums/pr-2450-followup

Conversation

@SamMorrowDrums
Copy link
Copy Markdown
Collaborator

@SamMorrowDrums SamMorrowDrums commented May 21, 2026

Follow-up to #2450 addressing review feedback. Targets `rosstarrant/add-csv-output-structure` so it can be merged into that branch before the parent PR lands.

Background

The team agreement is that insiders is a meta feature flag, much like all and default are meta toolsets. It expands once at startup into a set of individual feature flags, and from that point on the rest of the codebase should be feature flags all the way down — no special-casing of insiders elsewhere (UA string is the only intentional exception).

This PR pulls the implementation closer to that model.

Changes

Collapse CSV dual-variant pattern (pkg/github/csv_output.go)

withCSVOutputVariants registered each list_* default tool twice — once with FeatureFlagDisable=csv_output (JSON variant) and once with FeatureFlagEnable=csv_output (CSV variant). CSV is a pure response-format toggle: same name, same input schema, same output schema. It does not need the dual-name pattern.

Replaced with withCSVOutput, which wraps each tool's handler once and performs a runtime deps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) check inside the wrapped handler. Granular issues/PRs tools, which genuinely change schemas under their flag, are intentionally untouched and still use the dual-variant pattern.

Skip feature-flag filtering when no checker is installed (pkg/inventory/filters.go, pkg/http/handler.go)

Previously, an inventory built without a feature checker would treat every flag as off, and pkg/http had a bespoke StaticUpperBound helper that selectively skipped feature-flag filtering so that dual-name variants survived into the per-request inventory.

The static HTTP inventory now just doesn't install a checker, and isFeatureFlagAllowed short-circuits to true in that case. Result:

  • The static phase naturally returns the upper bound (all variants kept).
  • The per-request phase always installs a checker, so MCP registration — which can only serve a given tool name once — always sees a deduplicated set.
  • StaticUpperBound, isToolEnabledWithFeatureFlags, and the dedicated WithFeatureChecker argument to buildStaticInventory all go away.

Documentation and minor refactors (earlier commits)

  • Generic sortByToolsetThenName[T] to deduplicate the three sort wrappers.
  • Expanded doc comments on ResolveFeatureFlags clarifying that AllowedFeatureFlags and InsidersFeatureFlags are independent sets — user-passed flags are validated against AllowedFeatureFlags, but insiders may expand to flags outside that set, and AllowedFeatureFlags may include flags that insiders does not turn on.
  • Two extra test cases in feature_flags_test.go covering both directions of that independence.

Out of scope

  • The two CORS review threads — left for the original PR per @SamMorrowDrums' guidance. (MCPFeaturesHeader is already in cors.go:26.)

Test plan

  • script/lint — clean (0 issues)
  • script/test — passes
  • Net diff: -30 lines (130 insertions / 160 deletions)

Address review feedback on #2450:

- Collapse the three near-identical sort helpers in pkg/inventory/filters.go
  into a generic sortByToolsetThenName so adding new inventory item types
  doesn't require copying the comparator.
- Expand the doc comments on the three *WithoutFeatureFiltering helpers to
  spell out why they exist: HTTP mode builds a static (process-wide)
  inventory as an upper bound, but per-request feature flags from headers
  (X-MCP-Features, X-MCP-Insiders) are evaluated later, so feature-flagged
  variants must be preserved here.
- Strengthen the doc comment on ResolveFeatureFlags to make the contract
  explicit: user-supplied flags are validated against AllowedFeatureFlags,
  but insiders expansion deliberately is not — InsidersFeatureFlags may
  include server-controlled flags that are not user-toggleable.

CORS comments are intentionally left for the PR author.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums SamMorrowDrums requested a review from a team as a code owner May 21, 2026 12:31
Copilot AI review requested due to automatic review settings May 21, 2026 12:31
Also add tests covering:
- a user-toggleable flag (FeatureFlagIssuesGranular) that insiders does
  not turn on automatically
- insiders mode not turning on user-only allowed flags

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors inventory sorting to use a single generic helper and clarifies (via doc comments) the intended contract/behavior around feature-flag filtering and feature-flag resolution, aligning with the post-#2450 feature-flag model.

Changes:

  • Consolidated deterministic sorting for tools/resource templates/prompts into a generic sortByToolsetThenName helper.
  • Expanded doc comments explaining why Available*WithoutFeatureFiltering exists (static HTTP inventory must preserve feature-gated variants for per-request evaluation).
  • Strengthened ResolveFeatureFlags documentation to explicitly state that user-supplied flags are allowlisted while insiders expansion is not re-validated.
Show a summary per file
File Description
pkg/inventory/filters.go Introduces generic deterministic sort helper and clarifies feature-flag deferral rationale for HTTP static inventories.
pkg/github/feature_flags.go Documents the explicit contract for resolving user vs insiders feature flags.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 1

Comment thread pkg/inventory/filters.go
Comment on lines +110 to 116
func sortByToolsetThenName[T any](items []T, toolsetID func(T) ToolsetID, name func(T) string) {
sort.Slice(items, func(i, j int) bool {
if toolsetID(items[i]) != toolsetID(items[j]) {
return toolsetID(items[i]) < toolsetID(items[j])
}
return tools[i].Tool.Name < tools[j].Tool.Name
return name(items[i]) < name(items[j])
})
SamMorrowDrums and others added 2 commits May 21, 2026 14:37
…into StaticUpperBound

The three parallel methods (AvailableToolsWithoutFeatureFiltering,
AvailableResourceTemplatesWithoutFeatureFiltering,
AvailablePromptsWithoutFeatureFiltering) were always called as a triple
in exactly two places: HTTP buildStaticInventory and its test mirror.
They exist because the dual-variant pattern (sibling tools with mirrored
FeatureFlagEnable / FeatureFlagDisable on the same name, e.g. CSV output)
makes feature filtering at static-build time impossible — both variants
must be kept and resolved per-request.

Replace the three with one method, Inventory.StaticUpperBound(ctx), that
returns (tools, resources, prompts) and carries the rationale in its
doc comment. Reduces API surface, eliminates the triplication, and makes
the single "skip feature filtering" concept obvious to readers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two related simplifications, both about treating insiders as a meta flag
that expands once at startup and then stops mattering:

- Collapse CSV's dual-variant pattern into a single tool whose handler
  performs a runtime feature-flag check via deps.IsFeatureEnabled. CSV
  is a pure response-format toggle, not a schema change, so it does not
  need the dual-name pattern that genuine schema variants (granular
  issues/PRs) still use.

- When no feature checker is installed, skip feature-flag filtering and
  return the full upper bound. The static HTTP inventory now uses plain
  AvailableTools/Resources/Prompts; the per-request inventory always
  installs a checker, so MCP registration (which serves a tool name once)
  always sees a deduplicated set. The bespoke StaticUpperBound helper and
  the isToolEnabledWithFeatureFlags split go away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums SamMorrowDrums changed the title refactor: generic sort + clarify feature-flag intent (follow-up to #2450) Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker May 21, 2026
SamMorrowDrums and others added 3 commits May 21, 2026 14:53
The mcp-diff matrix now includes:
  - --insiders (and --insiders --read-only)
  - one config per github.AllowedFeatureFlags entry, generated by
    script/print-mcp-diff-configs so new user-controllable flags get
    diffed automatically without editing the workflow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a 'How feature flags are resolved' section covering:
  - Insiders is a meta flag, like 'all'/'default' for toolsets
  - User input -> allowlist filter -> insiders expansion ->
    server-side fallback (remote only)
  - AllowedFeatureFlags vs InsidersFeatureFlags are independent
  - How to add a new feature flag, including the
    TestGitHubPackageDoesNotReadInsidersMode guard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move tool feature-flag evaluation out of isToolEnabled and into a
ToolFilter installed at the head of the pipeline by Build() when
WithFeatureChecker received a non-nil checker. The 'no checker = no
filtering' contract is now expressed structurally (the filter isn't
installed) instead of by a runtime nil check inside the helper.

Resources and prompts have no filter pipeline, so they call the now-pure
featureFlagAllowed helper behind an explicit r.featureChecker != nil
guard at the iteration site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Avoid evaluating the extractor closures up to three times per comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@SamMorrowDrums SamMorrowDrums merged commit f59fce9 into rosstarrant/add-csv-output-structure May 21, 2026
15 checks passed
@SamMorrowDrums SamMorrowDrums deleted the sammorrowdrums/pr-2450-followup branch May 21, 2026 13:51
SamMorrowDrums added a commit that referenced this pull request May 21, 2026
#2450)

* Add CSV output for list tools under insiders mode

* fix: resolve rebase feature flag conflicts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker (#2516)

* refactor: generic toolset+name sort, clarify feature flag intent

Address review feedback on #2450:

- Collapse the three near-identical sort helpers in pkg/inventory/filters.go
  into a generic sortByToolsetThenName so adding new inventory item types
  doesn't require copying the comparator.
- Expand the doc comments on the three *WithoutFeatureFiltering helpers to
  spell out why they exist: HTTP mode builds a static (process-wide)
  inventory as an upper bound, but per-request feature flags from headers
  (X-MCP-Features, X-MCP-Insiders) are evaluated later, so feature-flagged
  variants must be preserved here.
- Strengthen the doc comment on ResolveFeatureFlags to make the contract
  explicit: user-supplied flags are validated against AllowedFeatureFlags,
  but insiders expansion deliberately is not — InsidersFeatureFlags may
  include server-controlled flags that are not user-toggleable.

CORS comments are intentionally left for the PR author.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(feature-flags): clarify allowed and insiders sets are independent

Also add tests covering:
- a user-toggleable flag (FeatureFlagIssuesGranular) that insiders does
  not turn on automatically
- insiders mode not turning on user-only allowed flags

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(inventory): collapse three *WithoutFeatureFiltering helpers into StaticUpperBound

The three parallel methods (AvailableToolsWithoutFeatureFiltering,
AvailableResourceTemplatesWithoutFeatureFiltering,
AvailablePromptsWithoutFeatureFiltering) were always called as a triple
in exactly two places: HTTP buildStaticInventory and its test mirror.
They exist because the dual-variant pattern (sibling tools with mirrored
FeatureFlagEnable / FeatureFlagDisable on the same name, e.g. CSV output)
makes feature filtering at static-build time impossible — both variants
must be kept and resolved per-request.

Replace the three with one method, Inventory.StaticUpperBound(ctx), that
returns (tools, resources, prompts) and carries the rationale in its
doc comment. Reduces API surface, eliminates the triplication, and makes
the single "skip feature filtering" concept obvious to readers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: simplify feature-flag handling

Two related simplifications, both about treating insiders as a meta flag
that expands once at startup and then stops mattering:

- Collapse CSV's dual-variant pattern into a single tool whose handler
  performs a runtime feature-flag check via deps.IsFeatureEnabled. CSV
  is a pure response-format toggle, not a schema change, so it does not
  need the dual-name pattern that genuine schema variants (granular
  issues/PRs) still use.

- When no feature checker is installed, skip feature-flag filtering and
  return the full upper bound. The static HTTP inventory now uses plain
  AvailableTools/Resources/Prompts; the per-request inventory always
  installs a checker, so MCP registration (which serves a tool name once)
  always sees a deduplicated set. The bespoke StaticUpperBound helper and
  the isToolEnabledWithFeatureFlags split go away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ci(mcp-diff): add insiders + per-feature configs

The mcp-diff matrix now includes:
  - --insiders (and --insiders --read-only)
  - one config per github.AllowedFeatureFlags entry, generated by
    script/print-mcp-diff-configs so new user-controllable flags get
    diffed automatically without editing the workflow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(insiders): explain feature-flag resolution for contributors

Adds a 'How feature flags are resolved' section covering:
  - Insiders is a meta flag, like 'all'/'default' for toolsets
  - User input -> allowlist filter -> insiders expansion ->
    server-side fallback (remote only)
  - AllowedFeatureFlags vs InsidersFeatureFlags are independent
  - How to add a new feature flag, including the
    TestGitHubPackageDoesNotReadInsidersMode guard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(inventory): make feature-flag gating a regular ToolFilter

Move tool feature-flag evaluation out of isToolEnabled and into a
ToolFilter installed at the head of the pipeline by Build() when
WithFeatureChecker received a non-nil checker. The 'no checker = no
filtering' contract is now expressed structurally (the filter isn't
installed) instead of by a runtime nil check inside the helper.

Resources and prompts have no filter pipeline, so they call the now-pure
featureFlagAllowed helper behind an explicit r.featureChecker != nil
guard at the iteration site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(inventory): cache extracted toolset IDs in sort comparator

Avoid evaluating the extractor closures up to three times per comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: correct MCP features header in cors

* docs: regenerate README for CSV output toolset

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove duplicate MCPFeaturesHeader from CORS headers

* ci(mcp-diff): add streamable-http job with header-based configs

Adds a sibling mcp-diff-http job that exercises the streamable-http
transport against a shared HTTP server, with per-config settings supplied
via X-MCP-* request headers — mirroring how the remote server is invoked
in production (server-side defaults + per-user header overrides).

The config generator gains a -transport flag:
- stdio (default, unchanged behaviour)
- http-headers (emits headers-only configs targeting a shared server)

Two new combined entries layer multiple headers together as a smoke test
for header-merging regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: regenerate after merging main

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Sam Morrow <info@sam-morrow.com>
Co-authored-by: sammorrowdrums <sammorrowdrums@github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants